feat(events): canonical per-family payload on event routes beside the native envelope (#466) - #545
Conversation
🦋 Changeset detectedLatest commit: 45403a2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6361fd7ce8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| "agent-bundle": patch | |||
There was a problem hiding this comment.
Mark the required canonical property as a minor change
Existing consumers that construct AgentEventCanonicalIdentity or event-route test inputs must now add the required canonical.payload property, so previously valid code can fail to compile after this release; the changeset summary itself acknowledges that break. Publishing it as a patch violates this repository's pre-1.0 rule and makes a breaking API change available through patch upgrades, so change this changeset to minor.
AGENTS.md reference: AGENTS.md:L102-L103
Useful? React with 👍 / 👎.
| export const agentEventPayloadFields = Object.freeze({ | ||
| 'agent/idle': [...sessionFields, 'teammateName', 'teamName'], |
There was a problem hiding this comment.
Deep-freeze the exported payload field arrays
When a JavaScript consumer, plugin, or casted TypeScript caller mutates an entry such as agentEventPayloadFields['tool/before'], Object.freeze protects only the outer record; the nested arrays remain mutable, and several families even share the same array instance. Because projectEventPayload iterates this exported table directly, a splice or push can silently change canonical payloads for all later route invocations in the process; use the repository's deepFreeze helper or freeze each array before exporting the table.
AGENTS.md reference: AGENTS.md:L17-L22
Useful? React with 👍 / 👎.
51b603b to
6971913
Compare
f7b1e8a to
35bd89d
Compare
… the native envelope (#466) AgentEventRouteProps<E>.canonical.payload carries the fields at least two hosts report for the route's family — tool name/input/response, session id, transcript path, cwd, prompt, agent id/type, stop re-entry, … — each as { value, nativeKey } naming the host key it was read from and absent when the host did not send it. The per-family table (agentEventPayloadFields) and the per-host key table (agentEventPayloadNativeKeys) live in routes/events.ts; events/payload.ts projects through them inside createCanonicalEventProps, so the standalone wrapper, the shared runtime, agent-bundle/test, and the Workbench replay all agree. Each pinned capability table mirrors its host's mapping under hooks.eventRoutes.<event>.payload (held equal by tests/event-payload.test.ts) and the generated events reference renders the field × host → native key matrix per family. agent-bundle/test gains createEventRouteInput; worktree-proximity and rsc-agent-runtime read canonical.payload instead of hand-parsing native; host-test keeps recording native as the host-specific example.
…required canonical.payload a minor bump (review)
35bd89d to
bb7db6b
Compare
Fixes #466.
What
Every event route now receives a canonical, per-family
canonical.payloadbeside the rawnativeenvelope.AgentEventRouteProps<E>(andAgentEventCanonicalIdentity<E>) take the route's family as a type parameter and narrowpayloadto that family's fields; the bareAgentEventRoutePropskeeps working with every field optional.{ value, nativeKey } | undefined(AgentEventPayloadField<T>): the decoded value beside the host key it was read from, so a route can tell mapped from missing and still name the host's spelling. A field the host did not send is absent — never defaulted, never inferred from another key. Two readings go beyond "read the key": Cursortool_output(a JSON string) is parsed intotoolResponseand kept as the string when it is not valid JSON; Cursorloop_countbecomes thereentryboolean (> 0). Claude/Codexstop_hook_activeisreentryas-is.task/*,file/change, …, Cursor'sworkspace/open— carry their defining fields). The rule is enforced by a test, which is howmodelended up on the nine three-host families only (Claude sends it onSessionStartalone).nativeis untouched,idempotencyKeystill hashes only{ event, native, target }(payload is derived, so a mapping change never re-identifies an event), andpermissionDecisionprojection from fix(events): tool/before pass-through projects no decision instead of allow (#461) #481 is not touched.Where the table lives
packages/agent-bundle/src/routes/events.ts— the one table:agentEventPayloadFieldTypes/agentEventPayloadFieldKinds(vocabulary + JSON shape),agentEventPayloadFields(per family, drivesAgentEventPayload<E>),agentEventPayloadNativeKeys(per host × family →{ nativeKey, decode? }). All exported fromagent-bundle,agent-bundle/routes,agent-bundle/api.packages/agent-bundle/src/events/payload.ts—projectEventPayload(event, native, target), called fromcreateCanonicalEventProps, which every surface goes through (standalone hook wrapper, shared runtime IPC,agent-bundle/test, Workbench replay). Unknown/portable/composite-unresolved hosts get{}.hooks.eventRoutes.<event>.payload(claude-2.1.250.json,codex-0.147.0.json,cursor-2026-08-28.json) so the generated events reference renders the matrix;tests/event-payload.test.tsholds the JSON equal to the runtime table field for field, and rejects a payload on an unsupported row or a missing one on a supported row.website/plugins/generated-reference.tsadds a "Canonical payload fields" section (one field × host table per family) toreference/events.Payload shape per family (field × host → native key;
—= host never sends it)Evidence: the live captures under
fixtures/host-lineage/(Claude 2.1.259, Codex 0.147.0, Cursor 3.18.25), the pinned Codex input schemas,validateNativeEventEnvelope, the Claude hooks reference ("Common input fields":modelreachesSessionStartonly;permission_mode"not all events";agent_id/agent_typeon every hook inside a subagent), and https://cursor.com/docs/hooks (cwdon tool events only; no permission mode;conversation_id). CursorsubagentStop.subagent_idis undocumented but observed on 3.18.25 and mapped if present.session/start— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typemodelmodelmodelmodelsourcesourcesourcetool/before— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typemodelmodelmodeltoolNametool_nametool_nametool_nametoolInputtool_inputtool_inputtool_inputtoolUseIdtool_use_idtool_use_idtool_use_idtool/after— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typemodelmodelmodeltoolNametool_nametool_nametool_nametoolInputtool_inputtool_inputtool_inputtoolUseIdtool_use_idtool_use_idtool_use_idtoolResponsetool_responsetool_responsetool_output→ json-stringstop— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typemodelmodelmodelreentrystop_hook_activestop_hook_activeloop_count→ positive-countlastAssistantMessagelast_assistant_messagelast_assistant_messageagent/start— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idsubagent_idagentTypeagent_typeagent_typesubagent_typemodelmodelmodelagent/stop— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idsubagent_idagentTypeagent_typeagent_typesubagent_typemodelmodelmodelagentTranscriptPathagent_transcript_pathagent_transcript_pathagent_transcript_pathreentrystop_hook_activestop_hook_activeloop_count→ positive-countlastAssistantMessagelast_assistant_messagelast_assistant_messageworkspace/open— cursorworkspaceRootsworkspace_rootssession/end— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typemodelmodelmodelreasonreasonreasonreasonprompt/submit— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typemodelmodelmodelpromptpromptpromptprompttool/failure— claude, cursorsessionIdsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtoolNametool_nametool_nametoolInputtool_inputtool_inputtoolUseIdtool_use_idtool_use_iderrorerrorerror_messageisInterruptis_interruptis_interruptcompact/before— claude, codex, cursorsessionIdsession_idsession_idconversation_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typemodelmodelmodeltriggertriggertriggertriggercompact/after— claude, codexsessionIdsession_idsession_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typetriggertriggertriggerpermission/request— claude, codexsessionIdsession_idsession_idcwdcwdcwdtranscriptPathtranscript_pathtranscript_pathpermissionModepermission_modepermission_modeagentIdagent_idagent_idagentTypeagent_typeagent_typetoolNametool_nametool_nametoolInputtool_inputtool_inputpermission/denied— claudesessionIdsession_idcwdcwdtranscriptPathtranscript_pathpermissionModepermission_modeagentIdagent_idagentTypeagent_typetoolNametool_nametoolInputtool_inputstop/failure— claudesessionIdsession_idcwdcwdtranscriptPathtranscript_pathpermissionModepermission_modeagentIdagent_idagentTypeagent_typeerrorerrorreentrystop_hook_activelastAssistantMessagelast_assistant_messagefile/change— claudesessionIdsession_idcwdcwdtranscriptPathtranscript_pathpermissionModepermission_modeagentIdagent_idagentTypeagent_typefilePathfile_pathconfig/change— claudesessionIdsession_idcwdcwdtranscriptPathtranscript_pathpermissionModepermission_modeagentIdagent_idagentTypeagent_typesourcesourcefilePathfile_pathtask/create— claudesessionIdsession_idcwdcwdtranscriptPathtranscript_pathpermissionModepermission_modeagentIdagent_idagentTypeagent_typetaskIdtask_idtaskSubjecttask_subjecttaskDescriptiontask_descriptionteammateNameteammate_nameteamNameteam_nametask/complete— claudesessionIdsession_idcwdcwdtranscriptPathtranscript_pathpermissionModepermission_modeagentIdagent_idagentTypeagent_typetaskIdtask_idtaskSubjecttask_subjecttaskDescriptiontask_descriptionteammateNameteammate_nameteamNameteam_nameagent/idle— claudesessionIdsession_idcwdcwdtranscriptPathtranscript_pathpermissionModepermission_modeagentIdagent_idagentTypeagent_typeteammateNameteammate_nameteamNameteam_nameDeliberately not modelled (single host, or unit unpinned): Cursor
attachments,generation_id,workspace_rootsoutsideworkspace/open,duration/duration_ms(Cursor'sdurationunit is not pinned),status,parent_conversation_id,task/summary; Claudeprompt_id,effort,background_tasks,session_crons,compact_summary,custom_instructions,permission_decision(_reason); Codexturn_id. All stay onnative. Turn/generation ids are alreadyrequest.lineage.generation.Tests
tests/event-payload.test.ts(unit): projects every event in the three live captures through the real table and checks each payload field back against the envelope (nativeKeymatches the mapping and the key exists; identity decodes deep-equal the native value; nothing mapped-and-present is dropped); the issue's acceptance —tool/beforereading onlytoolName/toolInputunder the Claude, Codex, and Cursor captures;stopreentrytrue forstop_hook_active: trueand Cursorloop_count: 1; Cursortool_outputparsed / kept as string; Cursorsession/starthas nosource/cwd/permissionMode,transcript_path: nullstaysnull, wrong shapes are dropped, portable/plugin/unsupported-family →{}; idempotency key unchanged by the payload; JSON mirror ≡ runtime table; the ≥ 2-hosts admission rule.tests/route-register-typegen.test.ts(typegen, against the published declarations): a route typedAgentEventRouteProps<'tool/after'>registersRegisteredRouteInput<'event:tool/after'>['canonical']asAgentEventCanonicalIdentity<'tool/after'>and itspayloadasAgentEventPayload<'tool/after'>;payload.reentryon it is a compile error; passing astopinput to it is a compile error;createEventRouteInputnarrows.tests/generated-route-server.test.ts: the real compiled hook wrapper → shared runtime → route path on Claude and Cursor now asserts the route sawtoolName@tool_nameand the parsedtoolResponse(tool_responseobject on Claude,tool_outputJSON string parsed on Cursor) whilenativekept the raw string.examples/worktree-proximity(10),examples/rsc-agent-runtime(3),examples/host-test(7) via the newcreateEventRouteInput; repo route-unit pool 58/58; projection pool 158/158; Workbench lifecycle client/model/page tests updated (payload rows + strict schema).Consumers
agent-bundle/testgainscreateEventRouteInput(event, native, { host, nativeEvent?, hostContractRevision?, validate? })— validates per host/event like the wrapper and returns the typed{ canonical, native }the harness takes. Hand-builtcanonicalobjects must now includepayload(the only breaking-ish edge; all in-repo callers migrated to the helper).worktree-proximity(src/event-support.tsnow takesAgentEventPayload—carriedChild,extractIntent,actorForWorktree; the five routes are typed to their families and no longer takenative),rsc-agent-runtime(src/events/tool/after.tsxreads cwd/sessionId/toolName/toolInput from the payload; onlyeditedPath— Claudefile_pathvs Codexapply_patchheader — stays host-specific inhook/normalize.ts, still shared with the hand-rolled hook CLI).host-testkeeps readingnativeon purpose: it is the probe that records the raw envelope, and its captures now includecanonical.payloadautomatically.hooks-and-scriptshas no event route (config hooks only; that surface is feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract #533's).field → value · nativeKey) under "Canonical payload"; the strict replay schema admits exactly{ nativeKey, value }per field.cargo-hauler sites that become deletable once pinned (not edited here)
src/lib/event-support.ts@ currentmain::54-67beforeShellEventFrom→canonical.payload.{cwd,sessionId,toolInput,toolName,toolUseId}?.value:69-77toolResponseFrom(object vs Cursor JSON string) →canonical.payload.toolResponse?.value:79-85afterShellEventFrom→ same as above plustoolResponse:87-96stopHookActiveFrom(stop_hook_activevsloop_count) →canonical.payload.reentry?.value:98-105stopHoldEventFrom→canonical.payload.{sessionId,reentry}:16-33nativeString/nativeRecord/parseJsonRecordhelpers → unused after the above:45-52hookContextFromstays (it is provenance, already oncanonical.provenance), and:124decisionValuestays, as the issue predicted.movie-library (
/fast/projects/agent-plugins/movie-library,15e9459): its only event routesrc/events/session/start.tsxreads neithernativenorcanonical, so nothing becomes deletable there.Coordination
HookHandler/HookEvent/HookResult, Typed hook handler contract: export HookHandler<'sessionStart'> / HookEvent / HookResult instead of restating the interface in docs and every example #488) landed first (0554f01e8), so this PR was rebased over it. Its table (adapters/hook-handler.ts:hookEventFields,HookEventPayloads) is the wrapper-decoded payload of the six plain-hook events — plain values,required/optionalper field, plus wrapper-only fields (effort,hookEventName,promptId,turnId); this PR's table (routes/events.ts) is the provenance-carrying payload of all 22 event-route families, admitted by the ≥2-hosts rule. Importing one into the other would have changed feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract #533's public types at merge time, so the two are held to one vocabulary instead:tests/event-payload.test.tsnow asserts every fieldhookEventFields[event].requiredguarantees is admitted for the matching family under the same name (stopHookActive→reentryis the one renaming), and the changeset says so. FoldingHookEventPayloadsontoagentEventPayloadFieldsis a follow-up, not done here.chore/claude-capability-2-1-260landed as chore(claude): re-pin the Claude Code capability contract 2.1.250 → 2.1.260 #542 (fe8b026f2,claude-2.1.250.json→claude-2.1.260.json), so this PR was rebased over it: the payload mirror was regenerated onto the 2.1.260 table (hooks.eventRoutes.<event>.payload, 22 rows), and the two families the re-pin admits —model-switch/before(PreModelSwitch) andmodel-switch/after(PostModelSwitch), Claude-only — are added tocanonicalAgentEventsinroutes/events.ts, toagentEventPayloadFieldswithfromModel/toModel/requestedModel(string | null) /sourcebeside the session fields, and to the Claude row ofagentEventPayloadNativeKeys. Theircache_ttl,context_tokens,estimated_cache_write_usd,pricing, andprompt_cache_warmstay innative. Tested againsttests/fixtures/events/claude-{pre,post}-model-switch.json.examples/worktree-proximity/src/events/session/start.tsx(feat(notices): address a notice to a lineage conversation or root (#458) #539ROOT_ACTOR_PREFIXbeside this PR'snonEmpty),examples/worktree-proximity/tests/route-unit/routes.test.ts(feat(notices): publisher-scoped notices.published() with per-notice state (#460) #541agent/runAgentRequestbesidecreateEventRouteInput),routes/public.ts(feat(runtime): expose the terminal capability to routes and scripts as request.terminal (#511) #534AgentTerminalimport; thecanonicalAgentEventsdefinition moved toroutes/events.tsnow carries themodel-switch/*entries),src/index.ts+routes/index.ts(feat(routes): let a route declare its render budget with config.render.maxElapsedMs (#454) #526MAX_ROUTE_RENDER_ELAPSED_MSbeside the payload exports), and the Claude capability JSON rename.Docs
website/docs/{en,zh}/guide/authoring/hooks.mdx(new "The canonical payload" section, route example typed to its family,createEventRouteInput),website/docs/{en,zh}/guide/development/testing.mdx,docs/entry-conventions.md(event-route contract row),examples/rsc-agent-runtime/README.md; generatedreference/eventsgains the per-family matrix.pnpm docs:site:buildpasses (dead-link/anchor/parity).Changeset
.changeset/466-canonical-event-payload.md—agent-bundle: minor. Opened aspatch(the runtime, artifact, wrapper, and output contracts are unchanged;nativeis unchanged; a route typed with the bareAgentEventRoutePropscompiles unchanged), then raised tominoron the reviewer's P1:payloadis a required property of the publicAgentEventCanonicalIdentity, so a downstream test or harness that constructs the identity by hand — every in-repo example did until this PR, and cargo-hauler's tests likely do — stops compiling until it adds one, which is consumer action under the pre-1.0 rule (minor= breaking). The fix on the consumer side iscreateEventRouteInput.Checks
pnpm typecheck,pnpm lintclean;pnpm test:unit3266/3266;pnpm test:route-unit58/58;pnpm test:projection158/158; integration subset (generated-route-serverevent tests,lifecycle-replay-dev-server,target-hook-contract,hook-playground-service,public-api,route-register-typegen,worktree-proximity-journeys) green;pnpm docs:site:buildpasses; the three examples'typecheck+ route-unit suites pass.Review status
6361fd7ce— Codex reviewed (2 threads). Both addressed in the follow-up commit: P1 changeset raised tominor(see Changeset above); P2agentEventPayloadFields,agentEventPayloadFieldKinds, andagentEventPayloadNativeKeysare nowdeepFreezed (the family arrays share instances, so apushfrom a JS consumer would have altered every later projection), with a test asserting the tables are frozen through.6971913e8,4fd5bafbe,798620e28,f7b1e8a60,35bd89de1,bb7db6b29, and anygh pr update-branchmerge commit after it) — no fresh review arrived on any of them; per the AGENTS.md quota fallback the last-reviewed head is6361fd7ce. The unreviewed delta is: the two-thread fix, six rebases onto a fast-movingmain(feat(test): mountTestState/withTestState — one mounted state across several renderRoute calls #525, ci: run required checks on merge_group for the merge queue #536, feat(notices): address a notice to a lineage conversation or root (#458) #539, feat(notices): publisher-scoped notices.published() with per-notice state (#460) #541, chore(claude): re-pin the Claude Code capability contract 2.1.250 → 2.1.260 #542, feat(hooks): export HookHandler / HookEvent / HookResult — the typed config hook handler contract #533, feat(runtime): expose the terminal capability to routes and scripts as request.terminal (#511) #534, feat(api): export the dev.runtime.provider protocol types, errors, store and registry contracts from agent-bundle/api #528, refactor(effect): run ordinary I/O of validators, services, eval, and post-build readers through FileSystem (phase 2, PR 1) #540, feat(routes): let a route declare its render budget with config.render.maxElapsedMs (#454) #526), themodel-switch/*admission and theHookEventoverlap test described under Coordination, and theclaude-2.1.260.jsonmirror. Merging on green CI viagh pr update-branch+gh pr merge --squash --auto(branch-protectedmain,strict: true, no--admin); any thread opened afterwards is answered in a follow-up PR.No PR comments are posted from this task; review threads are answered by commits and recorded here.